Skip to content

Add session-level auto-approve for AI file read operations#3101

Open
mits-pl wants to merge 18 commits intowavetermdev:mainfrom
mits-pl:feature/session-read-auto-approve
Open

Add session-level auto-approve for AI file read operations#3101
mits-pl wants to merge 18 commits intowavetermdev:mainfrom
mits-pl:feature/session-read-auto-approve

Conversation

@mits-pl
Copy link

@mits-pl mits-pl commented Mar 21, 2026

Summary

  • Adds an "Allow reading in this session" button to the AI tool approval UI alongside Approve/Deny
  • When clicked, all subsequent file reads from the same directory (and subdirectories) are auto-approved for the rest of the session
  • Session approvals are in-memory only — they reset on app restart for security

Changes

  • New file: pkg/aiusechat/sessionapproval.go — thread-safe in-memory registry for session-approved paths
  • Backend: tools_readfile.go / tools_readdir.goToolApproval checks session allowlist before requiring manual approval
  • RPC: New WaveAISessionReadApproveCommand to register approved paths from frontend
  • Frontend: "Allow reading in this session" button in both single and batch approval views

Security

  • Hardcoded sensitive file blocks (.ssh, .aws, credentials) in isBlockedFile() are enforced via ToolVerifyInput before ToolApproval — session approval cannot bypass them
  • Approvals are in-memory only, never persisted to disk
  • Approvals clear on app restart

Test plan

  • Open AI panel, ask AI to read a file → see Approve / Allow reading in this session / Deny
  • Click "Allow reading in this session" → current read is approved
  • Ask AI to read another file from the same directory → auto-approved without prompt
  • Ask AI to read a file from a different directory → still requires approval
  • Restart app → session approvals are cleared, approval required again
  • Verify sensitive files (e.g. ~/.ssh/id_rsa) are still blocked even with session approval

🤖 Generated with Claude Code

Adds an "Allow reading in this session" button to the AI tool approval UI.
When clicked, all subsequent file reads from the same directory are
auto-approved for the rest of the session (in-memory only, resets on restart).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 21, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a broad set of AI feature enhancements across frontend and backend. Key additions: session-scoped directory read-approval (frontend UI + RPC + in-memory registry with canonicalization and sensitive-path exclusions); MCP (Model Context Protocol) integration (MCP client, manager, config, HTTP handlers, UI widget, and tools wired into AI requests); persistent session-history and UI banners; multi-step “plan” management and UI panel; project-instructions discovery and a project_instructions tool; new web selector/read/SEO tools; terminal-run tool and execute-from-AI flow; many frontend panels, inputs, and small UX changes; multiple RPC/type updates and routing of MCP/state into AI request flow.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.65% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main feature being added: session-level auto-approval for AI file read operations.
Description check ✅ Passed The description is directly related to the changeset, providing a clear summary, listing key changes, explaining security considerations, and outlining test coverage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@CLAassistant
Copy link

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
frontend/app/aipanel/aitooluse.tsx (1)

205-212: Apply session approvals before sending final approve action.

At Line 211 and Line 291, handleApprove() runs immediately after firing session-approval RPCs. If those calls are delayed/fail, subsequent reads may still prompt unexpectedly despite the user choosing session allow.

💡 Proposed fix
-    const handleAllowSession = () => {
+    const handleAllowSession = async () => {
         const dirs = extractDirsFromParts(parts);
         const model = WaveAIModel.getInstance();
-        for (const dir of dirs) {
-            model.sessionReadApprove(dir);
-        }
+        await Promise.allSettled(dirs.map((dir) => model.sessionReadApprove(dir)));
         handleApprove();
     };
@@
-    const handleAllowSession = () => {
+    const handleAllowSession = async () => {
         const dir = extractDirFromToolDesc(toolData.tooldesc);
         if (dir) {
-            WaveAIModel.getInstance().sessionReadApprove(dir);
+            await WaveAIModel.getInstance().sessionReadApprove(dir);
         }
         handleApprove();
     };

Also applies to: 286-292

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/aipanel/aitooluse.tsx` around lines 205 - 212, The handler
handleAllowSession calls WaveAIModel.getInstance().sessionReadApprove(dir) in a
loop then immediately calls handleApprove(), which can race with async RPCs;
make handleAllowSession async, collect the promises returned by
sessionReadApprove (via extractDirsFromParts(parts)), await
Promise.all(promises) and only then call handleApprove(); apply the same change
to the other similar handler that performs sessionReadApprove before
handleApprove so approvals complete before sending the final approve action.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/app/aipanel/aitooluse.tsx`:
- Around line 80-94: The parent-directory extraction in extractDirFromToolDesc
only checks for '/'; change it to consider both '/' and backslash '\\' by
computing the last separator as the max of filePath.lastIndexOf('/') and
filePath.lastIndexOf('\\') and using that index to derive the parent directory
(preserving the existing special-case behavior for root/leading slash). Update
the logic around lastSlash to use this combined separator index so Windows paths
like "C:\..." return the correct parent directory.

In `@pkg/aiusechat/tools_readdir.go`:
- Around line 168-180: ToolApproval's auto-approval path for read_dir (in the
ToolApproval function that calls parseReadDirInput, wavebase.ExpandHomeDir and
IsSessionReadApproved) lacks the blocked-path guard used by read_text_file, so
session approvals can let protected dirs (e.g. ~/.ssh, ~/.aws or their parent
roots) be enumerated; modify ToolApproval to run the same blocked-path check
used by read_text_file (the verifier that enforces forbidden roots/paths) after
expanding the path and before returning ApprovalAutoApproved, and ensure the
check treats directory roots and parent paths as blocked (not just exact file
matches) so any path under a forbidden root is rejected and returns
ApprovalNeedsApproval.

In `@pkg/aiusechat/tools_readfile.go`:
- Around line 405-417: The current ToolApproval flow auto-approves files in
session-approved parent directories but isBlockedFile() only blocks a few exact
filenames and key-like names, so files under sensitive dirs (e.g., ~/.ssh/*,
~/.aws/*) still get auto-approved; update the isBlockedFile helper to reject
paths that have those directory prefixes (after expanding/home-normalizing the
path) so files like ~/.ssh/config and ~/.aws/config are denied even if their
parent dir is session-approved—locate isBlockedFile and ensure it checks
normalized directory prefixes (e.g., ".ssh", ".ssh/", ".aws", ".aws/") and
returns blocked before ToolApproval/IsSessionReadApproved is consulted, using
the same normalization logic as parseReadTextFileInput/wavebase.ExpandHomeDir.

---

Nitpick comments:
In `@frontend/app/aipanel/aitooluse.tsx`:
- Around line 205-212: The handler handleAllowSession calls
WaveAIModel.getInstance().sessionReadApprove(dir) in a loop then immediately
calls handleApprove(), which can race with async RPCs; make handleAllowSession
async, collect the promises returned by sessionReadApprove (via
extractDirsFromParts(parts)), await Promise.all(promises) and only then call
handleApprove(); apply the same change to the other similar handler that
performs sessionReadApprove before handleApprove so approvals complete before
sending the final approve action.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 1bb03a68-f057-48d5-aaf5-c5eb0e07b77b

📥 Commits

Reviewing files that changed from the base of the PR and between 8fc4dc3 and 20de8a8.

📒 Files selected for processing (10)
  • frontend/app/aipanel/aitooluse.tsx
  • frontend/app/aipanel/waveai-model.tsx
  • frontend/app/store/wshclientapi.ts
  • frontend/types/gotypes.d.ts
  • pkg/aiusechat/sessionapproval.go
  • pkg/aiusechat/tools_readdir.go
  • pkg/aiusechat/tools_readfile.go
  • pkg/wshrpc/wshclient/wshclient.go
  • pkg/wshrpc/wshrpctypes.go
  • pkg/wshrpc/wshserver/wshserver.go

programista-wordpress and others added 2 commits March 21, 2026 22:26
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sensitive paths (~/.ssh, ~/.aws, ~/.gnupg, ~/.kube, ~/.password-store,
~/.secrets, keychains, Windows credentials) are now excluded from
session auto-approval. Even if a parent directory like ~ is session-approved,
reads from these directories still require manual approval.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/aiusechat/sessionapproval.go`:
- Around line 29-42: The stored approved path must be fully canonicalized
(absolute + symlink-resolved and normalized case on Windows) before inserting
into globalSessionApproval.approvedPaths and candidate paths must be
canonicalized before any prefix checks; update AddSessionReadApproval to call
filepath.Abs then filepath.EvalSymlinks (fall back to the Abs/cleaned value only
if EvalSymlinks returns an error), normalize case on Windows (e.g.,
strings.ToLower), ensure a trailing filepath.Separator, and store that canonical
string in globalSessionApproval.approvedPaths; also update the code that checks
approvals (the code paths invoked by WaveAISessionReadApproveCommand) to apply
the same canonicalization to the requested path before comparing prefixes and
handle canonicalization errors by denying approval or returning a clear error.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 4cb7cfea-2255-44b5-93eb-6d605216c446

📥 Commits

Reviewing files that changed from the base of the PR and between 03ae5bd and 0ab2969.

📒 Files selected for processing (1)
  • pkg/aiusechat/sessionapproval.go

Use filepath.EvalSymlinks() on both stored approval roots and candidate
paths to prevent symlink-based bypass of session approval boundaries.
Also reject sensitive directories at registration time in AddSessionReadApproval.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
pkg/aiusechat/sessionapproval.go (1)

62-99: ⚠️ Potential issue | 🟠 Major

Normalize both sides before sensitive/prefix checks (case + canonical roots).

Line 67/Line 83 and Line 115 compare path strings directly, but only the candidate path is canonicalized. Sensitive roots and approval roots are not normalized to the same comparison form (notably case on Windows, and alias/symlink variants of protected roots), so sensitive-path detection and approval matching can be bypassed or behave inconsistently.

Suggested fix
 import (
 	"os"
 	"path/filepath"
+	"runtime"
 	"strings"
 	"sync"
@@
 func canonicalizePath(rawPath string) string {
@@
 	return resolved
 }
+
+func normalizePathForCompare(rawPath string) string {
+	p := filepath.Clean(canonicalizePath(rawPath))
+	if runtime.GOOS == "windows" {
+		p = strings.ToLower(p)
+	}
+	return p
+}
@@
 func AddSessionReadApproval(dirPath string) {
-	canonical := canonicalizePath(dirPath)
+	canonical := normalizePathForCompare(dirPath)
@@
 func isSensitivePath(expandedPath string) bool {
-	homeDir := os.Getenv("HOME")
-	if homeDir == "" {
-		homeDir = os.Getenv("USERPROFILE")
-	}
-	cleanPath := filepath.Clean(expandedPath)
+	homeDir, _ := os.UserHomeDir()
+	cleanPath := normalizePathForCompare(expandedPath)
@@
 	for _, dir := range sensitiveDirs {
-		dirWithSep := dir + string(filepath.Separator)
-		if cleanPath == dir || strings.HasPrefix(cleanPath, dirWithSep) {
+		normalizedDir := normalizePathForCompare(dir)
+		dirWithSep := normalizedDir + string(filepath.Separator)
+		if cleanPath == normalizedDir || strings.HasPrefix(cleanPath, dirWithSep) {
 			return true
 		}
 	}
@@
 	if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
-		credPath := filepath.Join(localAppData, "Microsoft", "Credentials")
+		credPath := normalizePathForCompare(filepath.Join(localAppData, "Microsoft", "Credentials"))
 		if cleanPath == credPath || strings.HasPrefix(cleanPath, credPath+string(filepath.Separator)) {
 			return true
 		}
 	}
 	if appData := os.Getenv("APPDATA"); appData != "" {
-		credPath := filepath.Join(appData, "Microsoft", "Credentials")
+		credPath := normalizePathForCompare(filepath.Join(appData, "Microsoft", "Credentials"))
 		if cleanPath == credPath || strings.HasPrefix(cleanPath, credPath+string(filepath.Separator)) {
 			return true
 		}
 	}
@@
 func IsSessionReadApproved(filePath string) bool {
-	canonical := canonicalizePath(filePath)
+	canonical := normalizePathForCompare(filePath)
#!/bin/bash
set -euo pipefail

echo "Inspect current comparison normalization in session approval logic:"
sed -n '27,125p' pkg/aiusechat/sessionapproval.go

echo
echo "Check whether case normalization is currently present:"
rg -n 'ToLower|EqualFold|runtime\.GOOS' pkg/aiusechat/sessionapproval.go

echo
echo "Check whether sensitive roots are canonicalized before comparison:"
rg -n 'sensitiveDirs|isSensitivePath|canonicalizePath\(|normalizePathForCompare' pkg/aiusechat/sessionapproval.go

Also applies to: 107-116

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/aiusechat/sessionapproval.go` around lines 62 - 99, The isSensitivePath
logic compares only the candidate path after filepath.Clean, which allows
mismatches on case-insensitive OSes and symlink/alias variants; update
isSensitivePath (and the related approval-root checks around lines referenced)
to canonicalize and normalize both sides before comparing: resolve abs +
EvalSymlinks (or fall back to Clean), normalize separators, and on Windows use
case-insensitive comparison (strings.EqualFold) or strings.ToLower for both the
cleaned expandedPath and each sensitiveDir/approvalRoot; ensure you canonicalize
every entry in sensitiveDirs (and the LOCALAPPDATA/APPDATA derived credPath)
prior to the equality/HasPrefix checks and use the same dirWithSep normalization
for prefix checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@pkg/aiusechat/sessionapproval.go`:
- Around line 62-99: The isSensitivePath logic compares only the candidate path
after filepath.Clean, which allows mismatches on case-insensitive OSes and
symlink/alias variants; update isSensitivePath (and the related approval-root
checks around lines referenced) to canonicalize and normalize both sides before
comparing: resolve abs + EvalSymlinks (or fall back to Clean), normalize
separators, and on Windows use case-insensitive comparison (strings.EqualFold)
or strings.ToLower for both the cleaned expandedPath and each
sensitiveDir/approvalRoot; ensure you canonicalize every entry in sensitiveDirs
(and the LOCALAPPDATA/APPDATA derived credPath) prior to the equality/HasPrefix
checks and use the same dirWithSep normalization for prefix checks.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 102c550f-4cb5-4391-8183-5281b3706968

📥 Commits

Reviewing files that changed from the base of the PR and between 0ab2969 and 42a16e7.

📒 Files selected for processing (1)
  • pkg/aiusechat/sessionapproval.go

JSON-RPC 2.0 over stdio client for MCP servers.
- Process spawn, handshake, tool discovery
- Manager singleton with per-CWD caching
- Auto-context (application-info, database-schema)
- HTTP API endpoints (/wave/mcp/status, /call, /calllog)
- Thread-safe call log with mutex
- Read timeout and graceful shutdown
- MCPAccess flag in WaveChatOpts and PostMessageRequest
- Auto-context injection (database schema, app info) into all AI backends
- MCP tools registered as AI tools (model calls them directly)
- MCP system prompt addon with usage guidelines
- Current date in system prompt
- MCP Context toggle in AI panel header (alongside Widget Context)
- Auto-detect .mcp.json in terminal CWD, show connect banner
- Inline MCP connect input from kebab menu
- mcpContextAtom, mcpCwdAtom in WaveAIModel
- New 'mcpclient' view in BlockRegistry with CWD input
- Tools list with Run button and expandable call log
- Registered in launcher sidebar
- HTTP endpoints wired in web.go
- web_read_text: extract innerText by CSS selector
- web_read_html: extract innerHTML by CSS selector
- web_seo_audit: full SEO audit (JSON-LD, OG, meta, headings, alt)
- Auto-refresh page before reading
- AI reading highlight animation on matched elements
- ExecJs option for arbitrary JS execution in webview
- InnerText option in WebSelectorOpts
- Save chat history per tab at shutdown
- Load and display in AI panel as expandable banner
- session_history tool for AI to read previous work
- Chat-to-tab mapping registry
- plan_create, plan_status, plan_update tools
- Plans persisted to disk, survive restart
- Live progress panel in AI panel with expandable steps
- Active plan auto-injected into AI context
- Dismiss/close plan button
- Context compiler: parse, tag by technology, filter by file extension
- Two-step tool: table of contents first, then request specific sections
- Reads all instruction files found (WAVE.md + CLAUDE.md combined)
- Max 8KB per file, 12KB for requested sections
- Reduce tool description lengths by 60-70%
- Consolidate plan/history/instructions into wave_utils multi-action tool
- Replace unicode characters with ASCII in AI-visible text
- Saves ~2000 tokens per request
- Fix Monaco URI to preserve file extension (.php not .php.orig)
- Add language detection from filename for 30+ extensions
- Support compound extensions (blade.php)
- Built-in presets: Claude, GPT-5, Gemini, MiniMax, Ollama, OpenRouter
- Quick Add Model submenu in kebab menu
- Inline API key input with secure storage
- Filter presets by available secrets (hide unconfigured)
- Ollama connectivity check before activation
- Fix: Wave Cloud modes always visible alongside BYOK presets
- Only show execute button for shell language code blocks
- Fix imports in aimessage component
@kilo-code-bot
Copy link
Contributor

kilo-code-bot bot commented Mar 22, 2026

Code Review Summary

Status: 4 Issues Previously Identified | Recommendation: Address remaining issues before merge

Overview

Severity Count
CRITICAL 2
WARNING 2

Previously Identified Issues (4 total)

Status: Partially Addressed

  1. Windows Path Separators (frontend/app/aipanel/aitooluse.tsx:90) - ✅ Addressed in code
  2. Symlink Canonicalization (pkg/aiusechat/sessionapproval.go:29-40) - ✅ Addressed in code
  3. Sensitive Directory Protection in read_dir (pkg/aiusechat/tools_readdir.go:180) - ⚠️ Mitigated via IsSessionReadApproved calling isSensitivePath, but defense-in-depth improvement possible
  4. Extended File Blocking (pkg/aiusechat/tools_readfile.go:417) - ⚠️ Mitigated via IsSessionReadApproved calling isSensitivePath, but defense-in-depth improvement possible

Additional Observations

Nitpick - Potential Race Condition:

  • In aitooluse.tsx:205-212 and :286-292, handleAllowSession calls RPC and immediately calls handleApprove() without awaiting. Consider making async and awaiting session approvals before approving.

Case Normalization (Windows):

  • sessionapproval.go does not normalize case for Windows paths. Consider using strings.ToLower or strings.EqualFold for case-insensitive comparison on Windows.

Security Model Analysis

The implementation provides adequate security through layered checks:

  • ToolVerifyInput blocks specific sensitive files via isBlockedFile
  • ToolApproval checks IsSessionReadApproved, which internally calls isSensitivePath to block sensitive directories
  • canonicalizePath uses filepath.EvalSymlinks to prevent symlink bypass
  • Session approvals are in-memory only and reset on restart

The existing critical issues have been addressed. The remaining warnings are suggestions for defense-in-depth improvements.

Files Reviewed (50+ files)
  • Session approval core: pkg/aiusechat/sessionapproval.go
  • Read tools: pkg/aiusechat/tools_readfile.go, pkg/aiusechat/tools_readdir.go
  • Frontend: frontend/app/aipanel/aitooluse.tsx, frontend/app/aipanel/waveai-model.tsx
  • RPC handlers: pkg/wshrpc/wshserver/wshserver.go

Reviewed by minimax-m2.5-20260211 · 2,899,886 tokens

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

♻️ Duplicate comments (1)
pkg/aiusechat/tools_readfile.go (1)

405-417: ⚠️ Potential issue | 🔴 Critical

Still unresolved: session auto-approve reaches unblocked ~/.ssh and ~/.aws configs.

At Line 414, any file under a session-approved parent becomes auto-approved, but isBlockedFile() still only blocks key-like files and a few exact paths. ~/.ssh/config, ~/.aws/config, and similar config files still pass ToolVerifyInput, so approving those directories for the session exposes sensitive material.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/aiusechat/tools_readfile.go` around lines 405 - 417, The session
auto-approve logic in ToolApproval can incorrectly approve sensitive config
files via an approved parent directory; update the closure in tools_readfile.go
so that after expanding the path (ExpandHomeDir(parsed.Filename)) you first
check isBlockedFile(expandedPath) (or a new helper that checks for blocked
ancestors) and return uctypes.ApprovalNeedsApproval if blocked, and only then
call IsSessionReadApproved(expandedPath) to return ApprovalAutoApproved; ensure
you reference parseReadTextFileInput, wavebase.ExpandHomeDir,
IsSessionReadApproved and isBlockedFile (or implement a blocked-ancestor check)
so ToolVerifyInput cannot be bypassed by approving a parent directory.
🟡 Minor comments (15)
frontend/app/monaco/monaco-react.tsx-141-144 (1)

141-144: ⚠️ Potential issue | 🟡 Minor

Avoid extension parsing from the full path

Line 141-Line 142 parse extensions from the entire path, so a dot in a directory name can be misread as a file extension and produce broken diff URIs.

💡 Proposed fix
-        const ext = path.includes(".") ? path.substring(path.lastIndexOf(".")) : "";
-        const basePath = path.includes(".") ? encodeURIComponent(path.substring(0, path.lastIndexOf("."))) : encodeURIComponent(path);
+        const lastSlash = path.lastIndexOf("/");
+        const dir = lastSlash >= 0 ? path.slice(0, lastSlash + 1) : "";
+        const fileName = lastSlash >= 0 ? path.slice(lastSlash + 1) : path;
+        const lastDot = fileName.lastIndexOf(".");
+        const hasExt = lastDot > 0;
+        const ext = hasExt ? fileName.slice(lastDot) : "";
+        const basePath = encodeURIComponent(hasExt ? `${dir}${fileName.slice(0, lastDot)}` : path);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/monaco/monaco-react.tsx` around lines 141 - 144, The code
currently derives ext and basePath by looking for a dot anywhere in the full
path, which mis-detects dots in directory names; update the logic that computes
ext and basePath used for origUri and modUri to first isolate the filename (use
path.basename on the incoming path) and then compute the extension and
name-from-filename (i.e., find the last dot on the filename only). Change the
variables ext and basePath (used when building origUri and modUri) so they are
based on the filename-derived values (encoded as before) to avoid treating
directory dots as file extensions.
frontend/app/view/codeeditor/diffviewer.tsx-35-42 (1)

35-42: ⚠️ Potential issue | 🟡 Minor

Make compound extension detection case-insensitive

Line 41 only matches lowercase "blade.php", so files like View.BLADE.PHP won’t get the intended html language.

💡 Proposed fix
 function getLanguageFromFileName(fileName: string): string | undefined {
-    const ext = fileName.split(".").pop()?.toLowerCase();
-    // Handle compound extensions like .blade.php
-    const parts = fileName.split(".");
-    if (parts.length >= 3) {
-        const compound = parts.slice(-2).join(".");
-        if (compound === "blade.php") return "html";
-    }
+    const normalized = fileName.toLowerCase();
+    if (normalized.endsWith(".blade.php")) return "html";
+    const ext = normalized.split(".").pop();
     return ext ? extToLanguage[ext] : undefined;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/view/codeeditor/diffviewer.tsx` around lines 35 - 42, The
compound-extension check in getLanguageFromFileName only matches "blade.php" in
lowercase so files like View.BLADE.PHP are missed; update the comparison to be
case-insensitive by normalizing the compound variable (e.g., call toLowerCase()
on parts.slice(-2).join(".")) before comparing and ensure any other
compound-checks use the same normalization (refer to function
getLanguageFromFileName and the compound variable).
pkg/aiusechat/projectctx/projectctx.go-137-140 (1)

137-140: ⚠️ Potential issue | 🟡 Minor

Truncation can break the closing tag.

At Lines 137-140, result[:maxLen] is taken after </project_instructions> has already been appended. If the slice lands inside that closing tag, the output contains a partial terminator plus another full terminator. Truncate the body first, then append the closing tag once.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/aiusechat/projectctx/projectctx.go` around lines 137 - 140, The
truncation currently slices `result` after the closing tag
`</project_instructions>` has been appended, which can split that tag; change
the logic in the function that builds `result` so you truncate the main body
before appending the closing tag: compute/truncate the body to `maxLen` (use the
same const maxLen = 8000) and then append `"\n...
[truncated]\n</project_instructions>"` (or the full closing tag) once; update
any references to `result` accordingly so the final `result` always contains a
single, intact `</project_instructions>` terminator.
pkg/mcpclient/config.go-17-28 (1)

17-28: ⚠️ Potential issue | 🟡 Minor

Handle missing .mcp.json paths in NormalizeMCPDir().

The comment says a file path like /path/to/.mcp.json is accepted, but if that file does not exist os.Stat() fails and the path is returned unchanged. LoadMCPConfig() then probes /path/to/.mcp.json/.mcp.json and returns an error instead of (nil, nil).

🛠️ Proposed fix
 func NormalizeMCPDir(dir string) string {
+	if filepath.Base(dir) == MCPConfigFileName {
+		return filepath.Dir(dir)
+	}
 	info, err := os.Stat(dir)
 	if err == nil && !info.IsDir() {
 		return filepath.Dir(dir)
 	}
 	return dir
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/mcpclient/config.go` around lines 17 - 28, NormalizeMCPDir currently
returns the input unchanged when os.Stat fails, which causes LoadMCPConfig to
append MCPConfigFileName twice for inputs like "/path/to/.mcp.json" that don't
yet exist; update NormalizeMCPDir to detect if filepath.Base(dir) ==
MCPConfigFileName and in that case return filepath.Dir(dir) (even if os.Stat
returns an error), otherwise keep the existing behavior (if os.Stat succeeds and
info.IsDir() is false return filepath.Dir(dir), else return dir). Refer to the
functions NormalizeMCPDir and LoadMCPConfig and the constant MCPConfigFileName
when making this change.
emain/emain-web.ts-52-65 (1)

52-65: ⚠️ Potential issue | 🟡 Minor

Register the load waiter before calling reload().

At Lines 53-59, wc.reload() fires before the did-finish-load listener is attached. On a fast or cached reload that can miss the event, force the timeout path, and then run executeJavaScript() while navigation is still in flight.

🛠️ Proposed fix
-        wc.reload();
-        await new Promise<void>((resolve) => {
-            const onFinish = () => {
-                wc.removeListener("did-finish-load", onFinish);
-                resolve();
-            };
-            wc.on("did-finish-load", onFinish);
-            // Timeout fallback in case did-finish-load doesn't fire
-            setTimeout(() => {
-                wc.removeListener("did-finish-load", onFinish);
-                resolve();
-            }, 10000);
-        });
+        await new Promise<void>((resolve) => {
+            const onFinish = () => {
+                clearTimeout(timeoutId);
+                resolve();
+            };
+            const timeoutId = setTimeout(() => {
+                wc.removeListener("did-finish-load", onFinish);
+                resolve();
+            }, 10000);
+            wc.once("did-finish-load", onFinish);
+            wc.reload();
+        });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@emain/emain-web.ts` around lines 52 - 65, The did-finish-load listener is
registered after calling wc.reload(), which can miss the event on fast loads;
move the listener registration for "did-finish-load" (the onFinish callback used
in the Promise) so it is attached to wc before invoking wc.reload(), keep the
10s timeout fallback and the wc.removeListener cleanup inside both the onFinish
and timeout branches, and then await that Promise before calling
executeJavaScript() so navigation has completed.
frontend/app/aipanel/aipanel.tsx-312-321 (1)

312-321: ⚠️ Potential issue | 🟡 Minor

Don't turn canceled requests into fake connection failures.

This branch rewrites any "aborted" error into a network banner, so a normal user cancel/stop path will look like connectivity broke.

🧯 Proposed fix
             let errorMsg = error.message || "An error occurred";
+            const normalized = errorMsg.toLowerCase();
+            if (normalized.includes("aborted")) {
+                return;
+            }
             if (
-                errorMsg.includes("fetch") ||
-                errorMsg.includes("network") ||
-                errorMsg.includes("ECONNREFUSED") ||
-                errorMsg.includes("Failed to fetch") ||
-                errorMsg.includes("timeout") ||
-                errorMsg.includes("aborted")
+                normalized.includes("fetch") ||
+                normalized.includes("network") ||
+                normalized.includes("econnrefused") ||
+                normalized.includes("failed to fetch") ||
+                normalized.includes("timeout")
             ) {
                 errorMsg = "Connection failed — check your internet connection and try again.";
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/aipanel/aipanel.tsx` around lines 312 - 321, The code is turning
cancelled requests into network-failure messages by treating any error message
containing "aborted" as a connection failure; update the logic around errorMsg
so that cancellations are not mapped to the network banner—remove "aborted" from
the generic includes check and instead detect real user/request cancellations
explicitly (e.g., check error.name === "AbortError" or error.code ===
"ERR_CANCELED" or instanceof DOMException with name "AbortError") and skip
mapping those to "Connection failed — check your internet connection and try
again." Reference the errorMsg variable and the includes-based condition to
locate and change the check.
pkg/aiusechat/aiplan/handler.go-30-37 (1)

30-37: ⚠️ Potential issue | 🟡 Minor

Use a non-safe verb for plan deletion.

This handler mutates state but accepts GET and currently doesn't reject any other method. That makes the delete route easier to trigger accidentally and inconsistent with the method guard in HandlePlanStatus. Please switch this endpoint to POST/DELETE and reject other methods here, with the caller in frontend/app/aipanel/planprogress.tsx updated to match.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/aiusechat/aiplan/handler.go` around lines 30 - 37, HandlePlanDelete
currently performs state mutation on an HTTP GET and does not guard methods;
change it to accept only POST or DELETE (pick one) and return a 405 for other
methods. Update the handler function HandlePlanDelete to check r.Method (e.g.,
allow "POST" or "DELETE" only), call DeletePlan(tabId) as before when allowed,
and write a proper JSON error response and 405 status for disallowed methods;
also update the caller in frontend/app/aipanel/planprogress.tsx to use the
chosen method instead of GET. Ensure Content-Type and success response remain
consistent when the delete is performed.
frontend/app/aipanel/sessionhistory.tsx-35-48 (1)

35-48: ⚠️ Potential issue | 🟡 Minor

Scope banner UI state to tabId.

session, expanded, and dismissed survive tab switches. A dismissed banner on one tab suppresses fetches for the next tab, and the previous tab's history can stay visible until the new request resolves. Reset or key these states by tabId before fetching the next session.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/aipanel/sessionhistory.tsx` around lines 35 - 48, The session,
expanded, and dismissed states currently persist across tab switches causing
stale UI and suppressed fetches; update the logic in the useEffect (and
surrounding component) to reset those states whenever tabId changes before
calling fetchSessionHistory: on tabId change call setSession(null),
setExpanded(false), setDismissed(false) (or derive these states keyed by tabId)
then invoke fetchSessionHistory(tabId) and setSession with the result; ensure
the cancellation guard (cancelled) still prevents races and keep tabId in the
dependency array so each tab gets a fresh fetch and UI state.
frontend/app/aipanel/planprogress.tsx-82-101 (1)

82-101: ⚠️ Potential issue | 🟡 Minor

Ignore stale fetches when tabId changes.

plan and minimized are carried across tab switches, and the polling effect doesn't guard against in-flight requests from the previous tab. That can briefly render the wrong plan or restore an old plan after a switch. Scope these states to tabId and ignore stale responses in the effect.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/aipanel/planprogress.tsx` around lines 82 - 101,
PlanProgressPanel currently preserves plan and minimized across tab switches and
lets in-flight fetchPlan calls from a previous tab overwrite state; scope these
states to tabId and ignore stale responses by resetting plan and minimized when
tabId changes and using a request token or AbortController inside the
useEffect/refresh flow to validate responses. Specifically, when tabId changes,
call setPlan(null) and setMinimized(false), and in refresh (used by
useEffect/setInterval) capture a local requestId or attach an AbortController
for each fetchPlan call and only call setPlan(result) if the requestId matches
the latest or the controller hasn't been aborted; also ensure the interval is
cleared and any controller aborted on cleanup so no stale promises update state.
Ensure dismissPlan continues to use the current tabId token/abort behavior so it
cannot revive state for a different tab.
frontend/app/aipanel/apikeyinput.tsx-17-34 (1)

17-34: ⚠️ Potential issue | 🟡 Minor

Guard handleSave against re-entry.

Enter still calls handleSave() while a save is in flight, so repeated key presses can enqueue multiple SetSecretsCommand requests even though the button is disabled. Add saving to the early-return guard and dependency list, and optionally disable the input while saving.

💡 Minimal fix
 const handleSave = useCallback(async () => {
-    if (!apiKey.trim() || !inputData) return;
+    if (saving || !apiKey.trim() || !inputData) return;
     setSaving(true);
     try {
         // Save API key as secret
         await RpcApi.SetSecretsCommand(TabRpcClient, {
             [inputData.secretName]: apiKey.trim(),
@@
-    }, [apiKey, inputData, model]);
+    }, [apiKey, inputData, model, saving]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/aipanel/apikeyinput.tsx` around lines 17 - 34, The handleSave
function can be re-entered while a save is in flight; update handleSave to
return early when saving is true (add "if (saving) return" to the existing
guard), include saving in the useCallback dependency array, and optionally
disable the input/UI while saving so users can’t trigger another save; locate
the handleSave function and related state (apiKey, inputData, saving, model) to
apply these changes.
pkg/aiusechat/tools_mcp.go-47-66 (1)

47-66: ⚠️ Potential issue | 🟡 Minor

Consider using the focused block from LayoutState instead of the first terminal.

getTerminalCwd returns the first terminal block with a cmd:cwd, but the Tab model exposes focused-block information via LayoutState.FocusedNodeId and LeafOrder. On tabs with multiple terminals, this can point MCP at a different project than the one actively in focus. Consider fetching the LayoutState (using tab.LayoutState) and resolving the focused node through LeafOrder to locate the active terminal before falling back to the first terminal.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/aiusechat/tools_mcp.go` around lines 47 - 66, getTerminalCwd currently
returns the first terminal block's cwd; change it to prefer the focused terminal
from the Tab's layout state: fetch tab.LayoutState, read
LayoutState.FocusedNodeId and resolve it through LayoutState.LeafOrder (or
equivalent mapping) to get the focused blockId, then try to load that block and
return its "cmd:cwd" if it's a terminal; if that lookup fails or the focused
node isn't a terminal, fall back to iterating tab.BlockIds as the current
implementation does. Update references in getTerminalCwd to use tab.LayoutState,
FocusedNodeId, and LeafOrder when resolving the active block before the existing
loop.
frontend/app/aipanel/mcpdetect.tsx-68-91 (1)

68-91: ⚠️ Potential issue | 🟡 Minor

Reset the dismissed state when the tab changes.

dismissed survives prop changes. After a user dismisses the banner in one tab, the early return on Lines 71-74 keeps suppressing MCP detection in later tabs too.

🪟 Simple reset
     const [detectInfo, setDetectInfo] = useState<MCPDetectInfo | null>(null);
     const [dismissed, setDismissed] = useState(false);
+
+    useEffect(() => {
+        setDismissed(false);
+    }, [tabId]);
 
     useEffect(() => {
         if (mcpEnabled || dismissed) {
             setDetectInfo(null);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/aipanel/mcpdetect.tsx` around lines 68 - 91, The dismissed flag
is persisting across tabs and suppressing detection; add a short effect that
resets dismissed when the active tab changes by adding useEffect(() => {
setDismissed(false); }, [tabId]) (referencing dismissed and setDismissed) so
each new tab re-runs MCP detection instead of inheriting the previous dismissal.
pkg/mcpclient/handler.go-146-157 (1)

146-157: ⚠️ Potential issue | 🟡 Minor

Same non-deterministic map iteration for server selection.

Similar to HandleMCPStatus, when req.Server is empty, the code iterates over configs map non-deterministically to pick a default server.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/mcpclient/handler.go` around lines 146 - 157, The code picks a default
server by iterating the configs map non-deterministically (serverName loop)
which can vary between runs; change this to select deterministically by
collecting the keys from LoadMCPConfig(req.Cwd) into a slice, sort them (e.g.,
alphabetical), and then set serverName to the first sorted key (or prefer a
named default key if present) before proceeding; update the same logic used in
HandleMCPStatus to use the sorted-key selection so serverName is deterministic.
pkg/aiusechat/aiplan/aiplan.go-102-141 (1)

102-141: ⚠️ Potential issue | 🟡 Minor

UpdateStep does not validate status parameter against allowed values.

The function accepts any string as status without validating it's one of the defined constants (StatusPending, StatusRunning, StatusDone, StatusFailed, StatusSkipped). This could lead to inconsistent plan states.

🛡️ Proposed fix to validate status
 func UpdateStep(tabId string, stepId int, status string, result string, errMsg string) (*Plan, error) {
+	// Validate status is a known value
+	switch status {
+	case StatusPending, StatusRunning, StatusDone, StatusFailed, StatusSkipped:
+		// valid
+	default:
+		return nil, fmt.Errorf("invalid status: %s", status)
+	}
+	
 	plansMu.Lock()
 	defer plansMu.Unlock()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/aiusechat/aiplan/aiplan.go` around lines 102 - 141, UpdateStep currently
accepts any string for the status parameter which can create invalid plan
states; add validation at the start of UpdateStep to ensure status is one of the
allowed constants (StatusPending, StatusRunning, StatusDone, StatusFailed,
StatusSkipped), return a descriptive error if not, and only apply the status
update to the matching step after validation; reference the UpdateStep function
and the status constants when implementing this check and ensure existing
behavior (result/error truncation, DoneAt timestamp, savePlanLocked) remains
unchanged.
pkg/mcpclient/handler.go-76-116 (1)

76-116: ⚠️ Potential issue | 🟡 Minor

Non-deterministic server selection when multiple servers are configured.

The loop for name := range configs iterates over a map, which has non-deterministic order in Go. This means the "first" server selected could vary between calls, leading to inconsistent behavior. If there are multiple servers and only one connects successfully, the user may get different results on repeated calls.

Consider sorting the server names for deterministic selection, or documenting this behavior:

♻️ Proposed fix for deterministic server selection
+import "sort"
+
 func HandleMCPStatus(w http.ResponseWriter, r *http.Request) {
 	...
+	// Sort server names for deterministic selection
+	var names []string
+	for name := range configs {
+		names = append(names, name)
+	}
+	sort.Strings(names)
+
 	var resp MCPStatusResponse
-	for name := range configs {
+	for _, name := range names {
 		client, err := manager.GetClient(cwd, name)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/mcpclient/handler.go` around lines 76 - 116, Handle the non-deterministic
map iteration in HandleMCPStatus by collecting the keys from configs, sorting
them, and iterating over the sorted slice instead of using "for name := range
configs"; this ensures deterministic server selection when calling
manager.GetClient(cwd, name) and building resp (using client.IsAlive(),
client.ServerInfo(), client.ListTools(), client.ListResources()) so repeated
calls produce consistent results.
🧹 Nitpick comments (15)
frontend/app/aipanel/aimessage.tsx (1)

130-135: Consider handling the case when no terminal block exists.

When findFirstTerminalBlockId() returns null, the function silently returns without feedback. Users clicking the execute button may not understand why nothing happened.

Additionally, RpcApi.ControllerInputCommand returns a Promise that isn't awaited or caught, which could lead to unhandled rejections if the RPC fails.

♻️ Suggested improvement with error handling
-function sendCommandToTerminal(cmd: string) {
+async function sendCommandToTerminal(cmd: string): Promise<boolean> {
     const blockId = findFirstTerminalBlockId();
-    if (!blockId) return;
+    if (!blockId) {
+        console.warn("No terminal block found to execute command");
+        return false;
+    }
     const b64data = stringToBase64(cmd + "\n");
-    RpcApi.ControllerInputCommand(TabRpcClient, { blockid: blockId, inputdata64: b64data });
+    try {
+        await RpcApi.ControllerInputCommand(TabRpcClient, { blockid: blockId, inputdata64: b64data });
+        return true;
+    } catch (e) {
+        console.error("Failed to send command to terminal", e);
+        return false;
+    }
 }

Alternatively, if visual feedback is desired, the callback could trigger a toast/notification when no terminal is available.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/aipanel/aimessage.tsx` around lines 130 - 135, The
sendCommandToTerminal function silently returns when findFirstTerminalBlockId()
yields null and does not handle RPC errors; update sendCommandToTerminal to (1)
provide user feedback when no terminal block exists (e.g., call an existing
toast/notification helper or emit an event) instead of silently returning, and
(2) await RpcApi.ControllerInputCommand(TabRpcClient, {...}) and wrap it in
try/catch to handle and log/display any error; reference the function
sendCommandToTerminal, findFirstTerminalBlockId, and
RpcApi.ControllerInputCommand when making the changes.
pkg/aiusechat/chatstore/chatstore.go (1)

52-62: GetAll() should deep-copy chats for consistency with Get().

GetAll() creates a new map but shares the underlying *uctypes.AIChat pointers with the store. This is inconsistent with Get(), which deep-copies the chat (including the NativeMessages slice) with an explicit comment stating it's to "prevent concurrent access issues." While current usage is read-only and safe, this shallow-copy pattern creates a maintenance risk—future callers might mutate returned chats and cause data races.

Align GetAll() with Get() by deep-copying each chat:

Proposed fix
 func (cs *ChatStore) GetAll() map[string]*uctypes.AIChat {
 	cs.lock.Lock()
 	defer cs.lock.Unlock()

 	result := make(map[string]*uctypes.AIChat, len(cs.chats))
 	for k, v := range cs.chats {
-		result[k] = v
+		copyChat := &uctypes.AIChat{
+			ChatId:         v.ChatId,
+			APIType:        v.APIType,
+			Model:          v.Model,
+			APIVersion:     v.APIVersion,
+			NativeMessages: make([]uctypes.GenAIMessage, len(v.NativeMessages)),
+		}
+		copy(copyChat.NativeMessages, v.NativeMessages)
+		result[k] = copyChat
 	}
 	return result
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/aiusechat/chatstore/chatstore.go` around lines 52 - 62, GetAll currently
returns a new map but reuses the store's *uctypes.AIChat pointers, risking
races; change GetAll to deep-copy each chat the same way Get() does (including
copying the NativeMessages slice and any nested fields) before inserting into
the result map. Locate GetAll and either call the existing Get() or
extract/reuse the deep-copy logic used by Get() to produce independent
*uctypes.AIChat instances while still holding cs.lock during the iteration, then
return the map of those copies.
frontend/app/aipanel/aimode.tsx (1)

152-157: Optimize to fetch secrets only when dropdown opens.

The effect runs on every isOpen change, including when closing the dropdown. This causes unnecessary API calls. Consider guarding the fetch to only run when isOpen is true.

♻️ Suggested fix
     // Load available secret names to filter byok presets
     useEffect(() => {
+        if (!isOpen) return;
         RpcApi.GetSecretsNamesCommand(TabRpcClient)
             .then((names) => setAvailableSecrets(new Set(names || [])))
             .catch(() => {});
     }, [isOpen]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/aipanel/aimode.tsx` around lines 152 - 157, The effect currently
triggers on every isOpen change (both open and close) causing unnecessary API
calls; update the useEffect that calls
RpcApi.GetSecretsNamesCommand(TabRpcClient) to first check that isOpen is true
and only then call the API, and ensure the result is passed to
setAvailableSecrets(new Set(names || [])); keep the existing .catch handler (or
add an abort/cleanup if you prefer) but do not call the API when isOpen is
false.
pkg/aiusechat/anthropic/anthropic-convertmessage.go (1)

147-150: LGTM on tool injection, but debug logging is incomplete.

The MCP tools are correctly cleaned and appended to the request, matching the pattern for TabTools. However, the debug logging below (lines 161-166) only collects tool names from chatOpts.Tools and chatOpts.TabTools, omitting chatOpts.MCPTools. This could make debugging MCP tool issues harder.

♻️ Suggested fix to include MCP tools in debug logging
 		for _, tool := range chatOpts.Tools {
 			toolNames = append(toolNames, tool.Name)
 		}
 		for _, tool := range chatOpts.TabTools {
 			toolNames = append(toolNames, tool.Name)
 		}
+		for _, tool := range chatOpts.MCPTools {
+			toolNames = append(toolNames, tool.Name)
+		}
 		if chatOpts.AllowNativeWebSearch {
 			toolNames = append(toolNames, "web_search[server]")
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/aiusechat/anthropic/anthropic-convertmessage.go` around lines 147 - 150,
The debug logging that builds the tool-name list currently iterates
chatOpts.Tools and chatOpts.TabTools but omits chatOpts.MCPTools, so update the
logging code to also iterate chatOpts.MCPTools (using the same Clean() or name
extraction logic used for other tool slices) and include those names in the
debug output; look for the block that collects tool names for logging and add a
loop over chatOpts.MCPTools (or map/append its cleaned names) so MCP tools
appear in the debug message alongside TabTools and Tools.
pkg/web/web.go (1)

24-27: Import ordering is slightly out of alphabetical order.

The imports are functional but mcpclient (line 27) should come after filestore alphabetically. This is a minor style issue.

♻️ Suggested import reordering
 	"github.com/wavetermdev/waveterm/pkg/aiusechat"
 	"github.com/wavetermdev/waveterm/pkg/aiusechat/aiplan"
 	"github.com/wavetermdev/waveterm/pkg/aiusechat/sessionhistory"
 	"github.com/wavetermdev/waveterm/pkg/authkey"
-	"github.com/wavetermdev/waveterm/pkg/mcpclient"
 	"github.com/wavetermdev/waveterm/pkg/filestore"
+	"github.com/wavetermdev/waveterm/pkg/mcpclient"
 	"github.com/wavetermdev/waveterm/pkg/panichandler"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/web/web.go` around lines 24 - 27, The import block in pkg/web/web.go is
out of alphabetical order: move the
"github.com/wavetermdev/waveterm/pkg/mcpclient" import so it appears after
"github.com/wavetermdev/waveterm/pkg/filestore" (i.e., sort the imports
alphabetically) to match project style; update the import ordering around the
existing imports like aiplan, sessionhistory, authkey, filestore and mcpclient
so mcpclient comes in the correct position.
pkg/aiusechat/sessionhistory/handler.go (1)

25-34: Reuse the package loader instead of duplicating file reads here.

This handler re-implements path lookup, ReadFile, and JSON unmarshal rather than going through LoadSessionHistory. Keeping two storage paths makes format/path changes easy to drift, and unexpected read/parse failures are currently flattened to {"entries": null} with no shared handling.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/aiusechat/sessionhistory/handler.go` around lines 25 - 34, The handler
currently duplicates file reading and JSON unmarshal (using getHistoryFilePath,
os.ReadFile, and json.Unmarshal into SessionLog) instead of the shared loader;
replace that block with a call to the package loader LoadSessionHistory(tabId)
and use its returned SessionLog (or error) so path/format logic is centralized;
on error, call the same shared response handling (writeEmptyResponse or the
loader's error handling) and remove the manual os.ReadFile/json.Unmarshal code
to avoid divergence.
pkg/aiusechat/usechat.go (4)

768-775: MCP CWD fallback duplicates CWD lookup logic.

This block fetches getTerminalCwd again even though it may have been called earlier (line 748). If the consolidation above is applied, tabCwd can be reused here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/aiusechat/usechat.go` around lines 768 - 775, The MCP CWD fallback calls
getTerminalCwd again instead of reusing the previously computed tabCwd; update
the block that sets chatOpts.MCPCwd (guarded by req.MCPAccess and checking
req.MCPCwd) to assign chatOpts.MCPCwd = req.MCPCwd when present and otherwise
set chatOpts.MCPCwd = tabCwd (the variable computed earlier) rather than calling
getTerminalCwd again; reference req.MCPAccess, req.MCPCwd, chatOpts.MCPCwd and
tabCwd when making the change.

193-211: Timeout error wrapping may mask the original error.

When the context is done, the error is replaced with a generic timeout message. This loses the original error information which could be valuable for debugging (e.g., distinguishing between network errors vs. actual timeouts).

Consider preserving the original error:

 	if err != nil {
 		if ctx.Err() != nil {
-			return nil, nil, fmt.Errorf("request timed out - check your internet connection and try again")
+			return nil, nil, fmt.Errorf("request timed out - check your internet connection and try again: %w", err)
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/aiusechat/usechat.go` around lines 193 - 211, In runAIChatStep, avoid
discarding the original error returned by backend.RunChatStep when ctx.Err() !=
nil; instead wrap or append the original err (from the RunChatStep call) into
the timeout message so the caller can inspect the underlying cause (e.g., use
fmt.Errorf("request timed out: %w", err) or include err.Error()), and only
replace the error when there is truly no underlying err; reference the
RunChatStep call and the ctx.Err() check to locate where to change the error
handling.

731-761: Multiple redundant tab ID checks and terminal CWD lookups.

The code checks req.TabId != "" four separate times (lines 733, 738, 747, 756) and calls getTerminalCwd in two places (749, 773). Consider consolidating these checks and caching the CWD lookup result:

♻️ Suggested consolidation
+	var tabCwd string
 	if req.TabId != "" {
 		sessionhistory.RegisterChatTab(req.ChatID, req.TabId)
-	}
-
-	// Notify AI about previous session history availability
-	if req.TabId != "" {
+		tabCwd = getTerminalCwd(r.Context(), req.TabId)
+		
+		// Notify AI about previous session history availability
 		prevSession := sessionhistory.LoadSessionHistory(req.TabId)
 		if prevSession != "" {
 			chatOpts.SystemPrompt = append(chatOpts.SystemPrompt,
 				"A previous session history exists...")
 		}
-	}
 
-	// Notify AI about project instructions file availability
-	if req.TabId != "" {
-		cwd := getTerminalCwd(r.Context(), req.TabId)
-		if cwd != "" && projectctx.FindInstructionsFile(cwd) != "" {
+		// Notify AI about project instructions file availability
+		if tabCwd != "" && projectctx.FindInstructionsFile(tabCwd) != "" {
 			chatOpts.SystemPrompt = append(chatOpts.SystemPrompt,
 				"Project coding instructions file found...")
 		}
-	}
 
-	// Inject active plan status if one exists for this tab
-	if req.TabId != "" {
+		// Inject active plan status if one exists for this tab
 		activePlan := aiplan.GetPlan(req.TabId)
 		if activePlan != nil && !aiplan.IsComplete(activePlan) {
 			chatOpts.SystemPrompt = append(chatOpts.SystemPrompt, aiplan.FormatPlanStatus(activePlan))
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/aiusechat/usechat.go` around lines 731 - 761, Consolidate the repeated
req.TabId != "" guards and duplicate getTerminalCwd calls by performing a single
if req.TabId != "" block that (1) calls
sessionhistory.RegisterChatTab(req.ChatID, req.TabId), (2) loads prevSession via
sessionhistory.LoadSessionHistory(req.TabId) and appends the session_history
system prompt if non-empty, (3) call getTerminalCwd(r.Context(), req.TabId) once
and cache it to use with projectctx.FindInstructionsFile(cwd) to append the
project_instructions prompt if found, and (4) fetch activePlan via
aiplan.GetPlan(req.TabId) and append aiplan.FormatPlanStatus(activePlan) when
activePlan != nil && !aiplan.IsComplete(activePlan); reuse the cached cwd and
ensure all appends go to chatOpts.SystemPrompt.

457-465: MCP generation errors are silently logged without notifying the user.

When generateMCPStateAndTools fails, the error is logged but the chat continues without MCP context. This could lead to confusing behavior where the user expects MCP tools to be available but they aren't. Consider sending a warning to the SSE stream so the frontend can display it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/aiusechat/usechat.go` around lines 457 - 465, When
generateMCPStateAndTools fails in the firstStep branch (the mcpErr != nil case),
emit a warning event to the SSE/chat stream so the frontend can display that MCP
tools/context are unavailable instead of silently continuing; keep setting
chatOpts.MCPState and chatOpts.MCPTools nil (or leave unchanged) and send a
short SSE payload (e.g., event type "warning" with a message including
mcpErr.Error()) so the UI can surface the problem. Locate the block using
generateMCPStateAndTools, mcpErr, chatOpts.MCPState and chatOpts.MCPTools and
call the project’s existing SSE emit function (or add one) to send a warning
event containing the error string and a clear user-facing message.
pkg/mcpclient/manager.go (2)

113-141: GetAutoContext does not limit the size of returned context.

If application-info or database-schema tools return very large outputs, the context string could become excessively large and consume significant tokens in the AI prompt. Consider adding truncation similar to truncateResult used elsewhere.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/mcpclient/manager.go` around lines 113 - 141, GetAutoContext builds an
unbounded context by appending raw outputs from client.CallTool for tools
"application-info" and "database-schema"; modify the code paths in
GetAutoContext (the loop over clients, the hasToolNamed + client.CallTool
blocks) to truncate long results before appending by calling the existing
truncateResult helper (or equivalent truncation util) with an appropriate max
length (same limit used elsewhere), and append the truncated string (and an
indicator like "...(truncated)") instead of the full result so the returned
context size is bounded.

221-231: Deleting from map while iterating is safe in Go, but could be clearer.

While Go allows deleting from a map during iteration, it's a pattern that can confuse readers. Consider collecting keys first or just iterating without deletion since clearing the map afterward would achieve the same result:

♻️ Alternative implementation
 func (m *MCPManager) Shutdown() {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
 	for key, client := range m.clients {
 		client.Close()
-		delete(m.clients, key)
+		_ = key // suppress unused warning
 	}
+	m.clients = make(map[string]*MCPClient)
 	log.Println("[mcpclient] all MCP clients shut down")
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/mcpclient/manager.go` around lines 221 - 231, The Shutdown method of
MCPManager currently deletes map entries while ranging (MCPManager.Shutdown
iterating m.clients and calling client.Close() then delete), which is legal but
confusing; change it to lock with m.mu.Lock(), iterate over m.clients and call
client.Close() for each entry without deleting, then clear the map in one step
(e.g., set m.clients = make(map[string]*MCPClient) or nil) after the loop to
make intent clear; keep the defer m.mu.Unlock() and ensure you still call
client.Close() on each MCP client.
pkg/aiusechat/aiplan/aiplan.go (1)

143-148: DeletePlan silently ignores removal errors.

The os.Remove error is not checked. While this may be intentional (e.g., file doesn't exist), unexpected errors like permission issues would go unnoticed. Consider at least logging the error.

♻️ Proposed fix to log errors
 func DeletePlan(tabId string) {
 	plansMu.Lock()
 	defer plansMu.Unlock()
-	os.Remove(getPlanFilePath(tabId))
+	if err := os.Remove(getPlanFilePath(tabId)); err != nil && !os.IsNotExist(err) {
+		log.Printf("[aiplan] warning: failed to delete plan for tab %s: %v", tabId, err)
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/aiusechat/aiplan/aiplan.go` around lines 143 - 148, The DeletePlan
function currently calls os.Remove(getPlanFilePath(tabId)) and ignores its
error; change it to capture the error (err := os.Remove(...)) and handle it: if
err != nil && !os.IsNotExist(err) then log the error (using the package logger
or the standard log package) so permission or other unexpected failures are
visible; keep the existing plansMu.Lock()/defer unlock and do not treat "file
not found" as an error.
pkg/mcpclient/handler.go (2)

208-211: writeJSON ignores encoding errors.

If json.NewEncoder(w).Encode(data) fails, the error is silently ignored. While rare, this could mask issues with response serialization.

♻️ Proposed fix to log errors
 func writeJSON(w http.ResponseWriter, data any) {
 	w.Header().Set("Content-Type", "application/json")
-	json.NewEncoder(w).Encode(data)
+	if err := json.NewEncoder(w).Encode(data); err != nil {
+		log.Printf("[mcpclient] warning: failed to encode JSON response: %v", err)
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/mcpclient/handler.go` around lines 208 - 211, The writeJSON function
currently ignores errors from json.NewEncoder(w).Encode(data); update writeJSON
to check the error returned by Encode and handle it (e.g., log the encoding
error and respond with an HTTP 500 if the response hasn't been written). Locate
writeJSON and change the call to capture err := json.NewEncoder(w).Encode(data),
then if err != nil use a logger or log.Printf to record the error (including the
data or context) and call http.Error(w, "internal server error",
http.StatusInternalServerError) or similar to notify the client.

125-130: Request body is read but defer r.Body.Close() comes after the read.

While technically not a bug (the body will be closed when the handler returns), it's more idiomatic to defer the close immediately after checking the error from ReadAll. More importantly, if ReadAll fails, the body should still be closed.

♻️ Suggested reorder
-	body, err := io.ReadAll(r.Body)
-	if err != nil {
-		writeJSON(w, MCPCallResponse{Error: "failed to read request body"})
-		return
-	}
-	defer r.Body.Close()
+	defer r.Body.Close()
+	body, err := io.ReadAll(r.Body)
+	if err != nil {
+		writeJSON(w, MCPCallResponse{Error: "failed to read request body"})
+		return
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/mcpclient/handler.go` around lines 125 - 130, Move the defer
r.Body.Close() so the request body is always closed even if io.ReadAll returns
an error: after calling io.ReadAll(r.Body) place defer r.Body.Close() before the
subsequent if err != nil check (or immediately upon entering the handler), then
keep the existing error branch that calls writeJSON(w, MCPCallResponse{Error:
...}) and returns; update only the ordering around io.ReadAll and r.Body.Close()
(references: io.ReadAll, r.Body.Close, MCPCallResponse, writeJSON).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: d999e1a2-7591-41b1-b8af-79378888a13d

📥 Commits

Reviewing files that changed from the base of the PR and between 42a16e7 and 54da2fc.

📒 Files selected for processing (58)
  • README.md
  • cmd/server/main-server.go
  • emain/emain-web.ts
  • frontend/app/aipanel/ai-utils.ts
  • frontend/app/aipanel/aimessage.tsx
  • frontend/app/aipanel/aimode.tsx
  • frontend/app/aipanel/aipanel-contextmenu.ts
  • frontend/app/aipanel/aipanel.tsx
  • frontend/app/aipanel/aipanelheader.tsx
  • frontend/app/aipanel/apikeyinput.tsx
  • frontend/app/aipanel/mcpdetect.tsx
  • frontend/app/aipanel/planprogress.tsx
  • frontend/app/aipanel/sessionhistory.tsx
  • frontend/app/aipanel/waveai-model.tsx
  • frontend/app/block/block.tsx
  • frontend/app/block/blockutil.tsx
  • frontend/app/element/streamdown.tsx
  • frontend/app/monaco/monaco-react.tsx
  • frontend/app/view/codeeditor/diffviewer.tsx
  • frontend/app/view/mcpclient/mcpclient.tsx
  • frontend/types/gotypes.d.ts
  • pkg/aiusechat/aiplan/aiplan.go
  • pkg/aiusechat/aiplan/handler.go
  • pkg/aiusechat/anthropic/anthropic-convertmessage.go
  • pkg/aiusechat/chatstore/chatstore.go
  • pkg/aiusechat/gemini/gemini-backend.go
  • pkg/aiusechat/openai/openai-convertmessage.go
  • pkg/aiusechat/openaichat/openaichat-convertmessage.go
  • pkg/aiusechat/projectctx/projectctx.go
  • pkg/aiusechat/sessionhistory/handler.go
  • pkg/aiusechat/sessionhistory/registry.go
  • pkg/aiusechat/sessionhistory/saver.go
  • pkg/aiusechat/sessionhistory/sessionhistory.go
  • pkg/aiusechat/tools.go
  • pkg/aiusechat/tools_history.go
  • pkg/aiusechat/tools_mcp.go
  • pkg/aiusechat/tools_plan.go
  • pkg/aiusechat/tools_projectctx.go
  • pkg/aiusechat/tools_readdir.go
  • pkg/aiusechat/tools_readfile.go
  • pkg/aiusechat/tools_screenshot.go
  • pkg/aiusechat/tools_term.go
  • pkg/aiusechat/tools_termrun.go
  • pkg/aiusechat/tools_utils.go
  • pkg/aiusechat/tools_web.go
  • pkg/aiusechat/tools_writefile.go
  • pkg/aiusechat/uctypes/uctypes.go
  • pkg/aiusechat/usechat-prompts.go
  • pkg/aiusechat/usechat.go
  • pkg/mcpclient/config.go
  • pkg/mcpclient/handler.go
  • pkg/mcpclient/manager.go
  • pkg/mcpclient/mcpclient.go
  • pkg/mcpclient/types.go
  • pkg/wconfig/defaultconfig/waveai.json
  • pkg/wconfig/defaultconfig/widgets.json
  • pkg/web/web.go
  • pkg/wshrpc/wshrpctypes.go
✅ Files skipped from review due to trivial changes (6)
  • pkg/wconfig/defaultconfig/widgets.json
  • pkg/aiusechat/tools_screenshot.go
  • README.md
  • pkg/aiusechat/tools_writefile.go
  • pkg/aiusechat/tools_term.go
  • pkg/mcpclient/types.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • pkg/aiusechat/tools_readdir.go
  • frontend/types/gotypes.d.ts
  • pkg/wshrpc/wshrpctypes.go

Comment on lines +69 to +87
if (opts?.execjs) {
const customExpr = `
(async () => {
try {
const result = await (async () => { ${opts.execjs} })();
if (Array.isArray(result)) {
return { value: result.map(String) };
}
return { value: [String(result)] };
} catch (error) {
return { error: error.message };
}
})()`;
const results = await wc.executeJavaScript(customExpr);
if (results.error) {
throw new Error(results.error);
}
return results.value;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C3 '\b(execjs|ExecJs)\b' emain pkg frontend
rg -n -C3 '\bwebGetSelector\s*\(' emain pkg frontend

Repository: wavetermdev/waveterm

Length of output: 2483


🏁 Script executed:

# Find the definition of seoAuditJS and the webReadContent function
rg -n "seoAuditJS" emain pkg frontend
rg -n "webReadContent" emain pkg frontend

Repository: wavetermdev/waveterm

Length of output: 885


🏁 Script executed:

# Check the RPC handler and how data flows from client to webGetSelector
rg -n -A10 "webGetSelector" emain/emain-wsh.ts

Repository: wavetermdev/waveterm

Length of output: 1029


🏁 Script executed:

# Find where WebSelectorOpts/WebGetOpts are constructed or passed
rg -n "WebSelectorOpts\|WebGetOpts" emain pkg frontend --type go --type ts

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# Find CommandWebSelectorData definition
rg -n "CommandWebSelectorData\|type.*WebSelector" pkg frontend --type ts --type go

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# Check where the webselector RPC endpoint is invoked from
rg -n "webselector\|CommandWebSelector" pkg frontend --type ts --type go -i

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# Verify seoAuditJS content to understand scope
sed -n '255,300p' pkg/aiusechat/tools_web.go

Repository: wavetermdev/waveterm

Length of output: 1531


🏁 Script executed:

# Search for CommandWebSelectorData and WebSelector type definitions
fd -e go -e ts | xargs grep -l "CommandWebSelector\|WebSelectorData" | head -20

Repository: wavetermdev/waveterm

Length of output: 258


🏁 Script executed:

# Look at wshrpc protocol definitions
find pkg/wshrpc -type f -name "*.go" | xargs grep -n "WebSelector\|webselector"

Repository: wavetermdev/waveterm

Length of output: 764


🏁 Script executed:

# Check if there's a protocol definition file that defines the command structure
fd -e proto -o -e ts -o -e go | xargs grep -l "webselector" 2>/dev/null | head -10

Repository: wavetermdev/waveterm

Length of output: 180


🏁 Script executed:

# Check wshclientapi.ts for WebSelectorCommand usage
rg -n -A5 "WebSelectorCommand" frontend/app/store/wshclientapi.ts

Repository: wavetermdev/waveterm

Length of output: 411


🏁 Script executed:

# Look at the full CommandWebSelectorData definition
sed -n '482,490p' pkg/wshrpc/wshrpctypes.go

Repository: wavetermdev/waveterm

Length of output: 376


🏁 Script executed:

# Search for who calls webselector or WebSelectorCommand from frontend
rg -n "webselector\|WebSelectorCommand" frontend --type ts

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# Find all calls to webGetSelector
rg -n "webGetSelector" emain frontend pkg --type ts --type go

Repository: wavetermdev/waveterm

Length of output: 384


🏁 Script executed:

# Check if webSelectorInput is parsed from user input
rg -n -B5 -A10 "webSelectorInput" pkg/aiusechat/tools_web.go

Repository: wavetermdev/waveterm

Length of output: 1424


🏁 Script executed:

# Search for tool definitions exposed to AI models
rg -n "func.*Tool\|registerTool\|AddTool" pkg/aiusechat/tools_web.go -A3

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# Find the tool definitions and how they're registered
rg -n "GetWebSelectorTools\|func.*Web.*Tool" pkg/aiusechat/tools_web.go -B2 -A15

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# Check how tools are used/exposed to AI models
rg -n "Tool\|Execute\|Function" pkg/aiusechat/ --type go | grep -i "web\|tool" | head -30

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# Look at the beginning of tools_web.go to understand the structure
head -150 pkg/aiusechat/tools_web.go

Repository: wavetermdev/waveterm

Length of output: 4305


🏁 Script executed:

# Find the tool function that invokes webReadContent with hardcoded opts
rg -n -B10 "GetWebSelectorToolDefinition\|GetWebReadContentTool" pkg/aiusechat/tools_web.go | head -100

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# Check if there's a registry of available tools and how they're used
rg -n "RegisterTool\|AvailableTools\|GetTool" pkg/aiusechat/ --type go | head -20

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# Look for the RPC handler registration and validation
rg -n "handle_webselector" emain pkg --type ts --type go -A5

Repository: wavetermdev/waveterm

Length of output: 527


🏁 Script executed:

# Search more carefully for tool definitions - look for GetWeb functions
rg -n "^func GetWeb" pkg/aiusechat/tools_web.go

Repository: wavetermdev/waveterm

Length of output: 370


🏁 Script executed:

# Check if there's any validation or filtering of opts in the RPC handler
rg -n -A20 "handle_webselector" emain/emain-wsh.ts

Repository: wavetermdev/waveterm

Length of output: 1108


🏁 Script executed:

# Search for how tools are exposed - look for tool lists or registrations
rg -n "GetAvailableTools\|tool.*list\|registerTools" pkg/aiusechat --type go

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# Check how the AI models use tools - are they exposed via tool definitions or direct RPC?
rg -n "ToolDefinition\|ToolAnyCallback" pkg/aiusechat/tools_web.go | head -30

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# Look at the GetWebSEOAuditToolDefinition to see how it calls webReadContent
sed -n '329,400p' pkg/aiusechat/tools_web.go

Repository: wavetermdev/waveterm

Length of output: 1227


🏁 Script executed:

# Check if there's any RPC request validation or if all RPC endpoints are freely callable
rg -n "RpcResponseHelper\|validateInput\|checkAccess" emain/emain-wsh.ts | head -20

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# Check if RPC endpoints are validated/filtered or if all can be called freely
rg -n "wshRpcCall\|rpc.*webselector" frontend --type ts -i

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# Look for the RPC server implementation to see if there's a whitelist of allowed commands
rg -n "RegisterCommand\|CommandHandler\|CommandMap" pkg/wshrpc --type go

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# Check if there's any sanitization or validation of opts before it reaches the handler
rg -n -B5 "data.opts" emain/emain-wsh.ts

Repository: wavetermdev/waveterm

Length of output: 361


🏁 Script executed:

# Check if there's authentication/authorization for RPC endpoints
rg -n "authenticat\|authoriz\|permission" pkg/wshrpc emain --type go --type ts | head -20

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# Look for how the RPC server is set up and if there's endpoint filtering
rg -n "NewWshServer\|RegisterCommand\|handleRequest" pkg/wshrpc --type go | head -20

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# Final check: Look at how frontend constructs and sends RPC calls - is there any input validation?
rg -n "wshRpcCall" frontend/app --type ts -A2 | head -30

Repository: wavetermdev/waveterm

Length of output: 1609


🏁 Script executed:

# Final verification: check if WebSelectorCommand is ever called from frontend, especially with user input
rg -n "WebSelectorCommand" frontend --type ts

Repository: wavetermdev/waveterm

Length of output: 212


🏁 Script executed:

# Check the exact signature and usage of WebSelectorCommand one more time
rg -n -B2 -A8 "WebSelectorCommand.*function\|WebSelectorCommand.*=>.*{" frontend/app/store/wshclientapi.ts

Repository: wavetermdev/waveterm

Length of output: 237


🏁 Script executed:

# Search for all calls to WebSelectorCommand in the entire codebase
rg -n "WebSelectorCommand\(" emain pkg frontend --type ts --type go

Repository: wavetermdev/waveterm

Length of output: 709


🏁 Script executed:

# Check if there are any other places where the webselector RPC is invoked
rg -n "webselector" frontend --type ts

Repository: wavetermdev/waveterm

Length of output: 383


🏁 Script executed:

# Look at the exact line where WebSelectorCommand is called in tools_web.go to see what opts are passed
sed -n '150,180p' pkg/aiusechat/tools_web.go

Repository: wavetermdev/waveterm

Length of output: 958


🏁 Script executed:

# Check if there's any validation in the RPC handler for opts parameter
rg -n -A30 "handle_webselector" emain/emain-wsh.ts

Repository: wavetermdev/waveterm

Length of output: 1491


🏁 Script executed:

# Search for the RPC server implementation - how are commands routed and validated
fd -e go -path "*/wshrpc/*" -o -path "*/wshutil/*" | xargs grep -l "dispatch\|route\|handle.*command" | head -5

Repository: wavetermdev/waveterm

Length of output: 232


🏁 Script executed:

# Check if there's any global RPC validation or filtering mechanism
rg -n "validateCommand\|authorizeCommand\|checkPermission" pkg/wshrpc pkg/wshutil --type go

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# Verify: is WebSelectorCommand EVER called from frontend besides in wshclientapi definition?
rg -n "\.WebSelectorCommand\|wshRpcCall.*webselector" frontend --type ts -i

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# Look at how RPC messages are routed - check wshutil or wshrpc server code
find pkg/wshrpc pkg/wshutil -name "*.go" | xargs grep -l "Route\|dispatch\|command" | head -3

Repository: wavetermdev/waveterm

Length of output: 173


🏁 Script executed:

# Check the RPC server code for how commands are dispatched
rg -n "func.*Route\|func.*Dispatch\|command.*=>" pkg/wshrpc --type go | head -20

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# Final verification: Can frontend construct arbitrary RPC calls or are they limited to wshclientapi methods?
rg -n "wshRpcCall\|wshclient" frontend/app/store --type ts | grep -v "wshclientapi\|wshclient.ts" | head -10

Repository: wavetermdev/waveterm

Length of output: 46


Add input validation for opts parameter in RPC handler to prevent arbitrary JavaScript execution.

At lines 69–86, this code executes arbitrary JavaScript if opts.execjs is populated. While current AI tool code paths hardcode safe values (only seoAuditJS is used for SEO auditing), the RPC handler at emain-wsh.ts:30 accepts and forwards data.opts directly without validation. Future code changes, frontend modifications, or misuse could introduce user/model-controlled input into execjs.

Add validation in handle_webselector to either:

  • Reject/filter unsupported fields in opts (block execjs entirely from RPC)
  • Only allow safe option combinations (whitelist-based validation)

This protects against accidental exposure and ensures the RPC boundary enforces intent, not just current code patterns.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@emain/emain-web.ts` around lines 69 - 87, The RPC handler handle_webselector
must validate the incoming opts argument to prevent arbitrary execution via
opts.execjs; update handle_webselector to either reject any opts containing the
execjs property or implement a strict whitelist of allowed option keys (e.g.,
only allow seoAuditJS or other explicit safe fields) and return an error when
unknown/blocked keys are present, ensuring that the opts object forwarded to the
emain-web path never contains execjs or other executable payloads; locate
references to opts and execjs in handle_webselector and the emain-web invocation
flow and enforce this validation before forwarding.

Comment on lines +18 to +31
// Local model (Ollama) - check if endpoint is reachable
try {
const resp = await fetch("http://localhost:11434/api/tags");
if (!resp.ok) throw new Error("not reachable");
// Mark as enabled and switch
await RpcApi.SetSecretsCommand(TabRpcClient, { "byok-local-enabled": "true" });
model.setAIMode(presetKey);
} catch {
globalStore.set(model.showApiKeyInput, {
presetKey,
secretName: "__ollama_error",
secretLabel: "Ollama not running. Start it with: ollama serve",
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't funnel Ollama connection failures through the API-key prompt.

The catch path stores a sentinel secretName: "__ollama_error" in showApiKeyInput, but ApiKeyInput treats any payload as a saveable secret. That means a failed Ollama reachability check can still let the user persist a bogus secret and switch into byok@ollama. Use a dedicated error banner/toast or add an explicit non-saveable error variant instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/aipanel/aipanel-contextmenu.ts` around lines 18 - 31, The
current catch path for the Ollama reachability check incorrectly reuses
model.showApiKeyInput (setting secretName "__ollama_error"), which allows
ApiKeyInput to persist a bogus secret; instead, stop funneling errors through
that saveable flow: replace the catch body so it does not call
globalStore.set(model.showApiKeyInput, ...). Either set a dedicated error state
(e.g., globalStore.set(model.showOllamaError, { presetKey, message: "Ollama not
running. Start it with: ollama serve" })) or call the existing UI
notification/toast API to surface the error; keep RpcApi.SetSecretsCommand and
model.setAIMode unchanged in the success branch and ensure ApiKeyInput only ever
receives genuine secret payloads (do not use secretName "__ollama_error").

Comment on lines +290 to +300
const mcpCwd = globalStore.get(model.mcpCwdAtom);
const body: any = {
msg,
chatid: globalStore.get(model.chatId),
widgetaccess: globalStore.get(model.widgetAccessAtom),
mcpaccess: globalStore.get(model.mcpContextAtom),
aimode: globalStore.get(model.currentAIMode),
};
if (mcpCwd) {
body.mcpcwd = mcpCwd;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't send mcpcwd when MCP context is disabled.

mcpcwd is still included whenever it's populated, even if mcpaccess is false. That means turning MCP context off still sends project-path context in the request.

🔒 Proposed fix
-                const mcpCwd = globalStore.get(model.mcpCwdAtom);
+                const mcpAccess = globalStore.get(model.mcpContextAtom);
+                const mcpCwd = globalStore.get(model.mcpCwdAtom);
                 const body: any = {
                     msg,
                     chatid: globalStore.get(model.chatId),
                     widgetaccess: globalStore.get(model.widgetAccessAtom),
-                    mcpaccess: globalStore.get(model.mcpContextAtom),
+                    mcpaccess: mcpAccess,
                     aimode: globalStore.get(model.currentAIMode),
                 };
-                if (mcpCwd) {
+                if (mcpAccess && mcpCwd) {
                     body.mcpcwd = mcpCwd;
                 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const mcpCwd = globalStore.get(model.mcpCwdAtom);
const body: any = {
msg,
chatid: globalStore.get(model.chatId),
widgetaccess: globalStore.get(model.widgetAccessAtom),
mcpaccess: globalStore.get(model.mcpContextAtom),
aimode: globalStore.get(model.currentAIMode),
};
if (mcpCwd) {
body.mcpcwd = mcpCwd;
}
const mcpAccess = globalStore.get(model.mcpContextAtom);
const mcpCwd = globalStore.get(model.mcpCwdAtom);
const body: any = {
msg,
chatid: globalStore.get(model.chatId),
widgetaccess: globalStore.get(model.widgetAccessAtom),
mcpaccess: mcpAccess,
aimode: globalStore.get(model.currentAIMode),
};
if (mcpAccess && mcpCwd) {
body.mcpcwd = mcpCwd;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/aipanel/aipanel.tsx` around lines 290 - 300, The request body is
adding body.mcpcwd whenever mcpCwd exists even if MCP context is disabled;
change the logic in aipanel.tsx so mcpcwd is only added when mcpCwd is present
AND mcp access is enabled: read the mcp access value (model.mcpContextAtom / the
mcpaccess value currently placed into body) into a local variable (or use the
already-read value) and only set body.mcpcwd when both mcpCwd and that mcp
access flag are true, removing the unconditional addition that uses body.mcpcwd.

Comment on lines +23 to +45
<div className="flex items-center text-sm whitespace-nowrap">
<span className="text-gray-300 @xs:hidden mr-1 text-[12px]">{shortLabel}</span>
<span className="text-gray-300 hidden @xs:inline mr-2 text-[12px]">{label}</span>
<button
onClick={onToggle}
className={`relative inline-flex h-6 w-14 items-center rounded-full transition-colors cursor-pointer ${
enabled ? "bg-accent-600" : "bg-zinc-600"
}`}
title={title}
>
<span
className={`absolute inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? "translate-x-8" : "translate-x-1"
}`}
/>
<span
className={`relative z-10 text-xs text-white transition-all ${
enabled ? "ml-2.5 mr-6 text-left" : "ml-6 mr-1 text-right"
}`}
>
{enabled ? "ON" : "OFF"}
</span>
</button>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Expose a real labeled switch to assistive tech.

The visible label is outside the button, so the accessible name here is effectively just "ON" / "OFF". Add switch semantics and an explicit label, otherwise screen-reader users can't tell the two toggles apart.

♿ Proposed fix
             <button
+                type="button"
+                role="switch"
+                aria-checked={enabled}
+                aria-label={label}
                 onClick={onToggle}
                 className={`relative inline-flex h-6 w-14 items-center rounded-full transition-colors cursor-pointer ${
                     enabled ? "bg-accent-600" : "bg-zinc-600"
                 }`}
                 title={title}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className="flex items-center text-sm whitespace-nowrap">
<span className="text-gray-300 @xs:hidden mr-1 text-[12px]">{shortLabel}</span>
<span className="text-gray-300 hidden @xs:inline mr-2 text-[12px]">{label}</span>
<button
onClick={onToggle}
className={`relative inline-flex h-6 w-14 items-center rounded-full transition-colors cursor-pointer ${
enabled ? "bg-accent-600" : "bg-zinc-600"
}`}
title={title}
>
<span
className={`absolute inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? "translate-x-8" : "translate-x-1"
}`}
/>
<span
className={`relative z-10 text-xs text-white transition-all ${
enabled ? "ml-2.5 mr-6 text-left" : "ml-6 mr-1 text-right"
}`}
>
{enabled ? "ON" : "OFF"}
</span>
</button>
<div className="flex items-center text-sm whitespace-nowrap">
<span className="text-gray-300 `@xs`:hidden mr-1 text-[12px]">{shortLabel}</span>
<span className="text-gray-300 hidden `@xs`:inline mr-2 text-[12px]">{label}</span>
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label={label}
onClick={onToggle}
className={`relative inline-flex h-6 w-14 items-center rounded-full transition-colors cursor-pointer ${
enabled ? "bg-accent-600" : "bg-zinc-600"
}`}
title={title}
>
<span
className={`absolute inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? "translate-x-8" : "translate-x-1"
}`}
/>
<span
className={`relative z-10 text-xs text-white transition-all ${
enabled ? "ml-2.5 mr-6 text-left" : "ml-6 mr-1 text-right"
}`}
>
{enabled ? "ON" : "OFF"}
</span>
</button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/aipanel/aipanelheader.tsx` around lines 23 - 45, The toggle
button currently exposes only the "ON"/"OFF" text so screen readers cannot
distinguish controls; update the button (the element with onClick={onToggle}) to
include proper switch semantics by adding role="switch", aria-checked={enabled},
and a clear accessible label via either aria-label or aria-labelledby pointing
to the visible label (create a visually-hidden <span id="..."> or reuse the
existing label spans and reference their id). Also add type="button" to the
button to avoid accidental form submission and keep the existing visual text
("ON"/"OFF") unchanged.

Comment on lines +276 to +282
try {
const [statusResp, logResp] = await Promise.all([fetchMCPStatus(cwd), fetchCallLog()]);
setStatus(statusResp);
setCallLogEntries(logResp || []);
} catch {
setStatus({ connected: false, error: "Failed to fetch status" });
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't couple server health to the call-log endpoint.

Promise.all makes a /wave/mcp/calllog failure fall into the same catch as a real status failure, so Line 281 can mark a healthy MCP server as disconnected. Fetch these independently or use Promise.allSettled.

🩹 One straightforward fix
         setLoading(true);
         try {
-            const [statusResp, logResp] = await Promise.all([fetchMCPStatus(cwd), fetchCallLog()]);
-            setStatus(statusResp);
-            setCallLogEntries(logResp || []);
+            const [statusResp, logResp] = await Promise.allSettled([fetchMCPStatus(cwd), fetchCallLog()]);
+
+            if (statusResp.status === "fulfilled") {
+                setStatus(statusResp.value);
+            } else {
+                setStatus({ connected: false, error: "Failed to fetch status" });
+            }
+
+            if (logResp.status === "fulfilled") {
+                setCallLogEntries(logResp.value || []);
+            } else {
+                setCallLogEntries([]);
+            }
         } catch {
             setStatus({ connected: false, error: "Failed to fetch status" });
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const [statusResp, logResp] = await Promise.all([fetchMCPStatus(cwd), fetchCallLog()]);
setStatus(statusResp);
setCallLogEntries(logResp || []);
} catch {
setStatus({ connected: false, error: "Failed to fetch status" });
}
try {
const [statusResp, logResp] = await Promise.allSettled([fetchMCPStatus(cwd), fetchCallLog()]);
if (statusResp.status === "fulfilled") {
setStatus(statusResp.value);
} else {
setStatus({ connected: false, error: "Failed to fetch status" });
}
if (logResp.status === "fulfilled") {
setCallLogEntries(logResp.value || []);
} else {
setCallLogEntries([]);
}
} catch {
setStatus({ connected: false, error: "Failed to fetch status" });
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/view/mcpclient/mcpclient.tsx` around lines 276 - 282, The
current Promise.all call couples fetchMCPStatus and fetchCallLog so a call-log
failure will mark the MCP as disconnected; change the logic in the component
where fetchMCPStatus and fetchCallLog are awaited (currently using Promise.all)
to handle them independently—either await fetchMCPStatus alone and
setStatus(statusResp) and then separately await fetchCallLog and
setCallLogEntries(logResp || []), or use Promise.allSettled on
[fetchMCPStatus(), fetchCallLog()] and: if the status promise is fulfilled call
setStatus with its value (or set connected:false on rejection), and if the
callLog promise is fulfilled call setCallLogEntries with the result otherwise
setCallLogEntries([]); keep references to fetchMCPStatus, fetchCallLog,
setStatus, and setCallLogEntries so the changes are easy to locate.

Comment on lines +143 to +149
case "plan_update":
stepId := int(params["step_id"].(float64))
status, _ := params["status"].(string)
result, _ := params["result"].(string)
if stepId == 0 {
return "", fmt.Errorf("step_id required")
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Validate step_id before converting it.

wave_utils only requires action, so { "action": "plan_update" } is schema-valid today. The direct type assertion on Line 144 will panic instead of returning a normal tool error.

🐛 Safer guard
 			case "plan_update":
-				stepId := int(params["step_id"].(float64))
+				stepIdFloat, ok := params["step_id"].(float64)
+				if !ok || int(stepIdFloat) == 0 {
+					return "", fmt.Errorf("plan_update requires step_id")
+				}
+				stepId := int(stepIdFloat)
 				status, _ := params["status"].(string)
 				result, _ := params["result"].(string)
-				if stepId == 0 {
-					return "", fmt.Errorf("step_id required")
-				}
 				if status == "" {
 					status = aiplan.StatusDone
 				}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case "plan_update":
stepId := int(params["step_id"].(float64))
status, _ := params["status"].(string)
result, _ := params["result"].(string)
if stepId == 0 {
return "", fmt.Errorf("step_id required")
}
case "plan_update":
stepIdFloat, ok := params["step_id"].(float64)
if !ok || int(stepIdFloat) == 0 {
return "", fmt.Errorf("plan_update requires step_id")
}
stepId := int(stepIdFloat)
status, _ := params["status"].(string)
result, _ := params["result"].(string)
if status == "" {
status = aiplan.StatusDone
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/aiusechat/tools_utils.go` around lines 143 - 149, In the "plan_update"
case, avoid directly asserting params["step_id"].(float64) which can panic;
first check that params["step_id"] exists and is the expected numeric type (use
a type switch or an "ok" style assertion) and return a clear error if it's
missing or not a number, then convert to int and keep the existing zero check
(stepId == 0) and subsequent logic; update the handling around
params["step_id"], stepId, and any error returns in that case block to be
defensive and non-panicking.

Comment on lines +15 to 16
`You can execute commands in the user's terminal using the term_run_command tool. Use it when the user asks you to run a command, check something via CLI, or when running a command would help answer their question (e.g., php artisan, composer, npm, git, ls, etc.). The command requires user approval before execution.`,

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Gate the term_run_command instructions behind the actual tool list.

GenerateTabStateAndTools only appends term_run_command when the tab has a term widget, so this global prompt gives the model a capability it often doesn't have. The approval sentence also nudges it toward asking first instead of calling the tool and letting runtime approval handle it.

Based on learnings: avoid mentioning approval requirements in tool guidance because it causes redundant pre-approval prompts instead of relying on runtime approval.

Also applies to: 39-41

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/aiusechat/usechat-prompts.go` around lines 15 - 16, The global prompt
block currently mentions the term_run_command tool and its "requires user
approval" line even when the tab lacks a terminal; update
GenerateTabStateAndTools so that any guidance about term_run_command (including
the approval sentence) is not in the global prompts but is appended only when
GenerateTabStateAndTools detects a tab with a term widget and adds
term_run_command to the tool list; remove the approval sentence from global
guidance entirely and ensure the runtime approval flow is relied upon instead of
nudging pre-approval in the prompt; reference the term_run_command identifier
and the GenerateTabStateAndTools function when making this change.

Comment on lines +43 to 45
// Plans
`For complex multi-step tasks (auditing multiple pages, processing multiple files, etc.), use the plan_create tool to create an execution plan. Execute one step at a time, call plan_update to record the result, then continue with the next step. If you see an <active_plan> block in your context, continue executing the next pending step immediately.`,

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Align the planning instructions with wave_utils.

The tool wiring added in this PR advertises a single wave_utils tool, but this prompt tells the model to call standalone plan_create / plan_update tools. As written, the model will emit invalid tool names unless aliases exist elsewhere.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/aiusechat/usechat-prompts.go` around lines 43 - 45, Update the planning
instruction string in usechat-prompts.go so it references the single advertised
tool "wave_utils" instead of standalone tools; replace the guidance to "call
plan_create / plan_update" with instructions to invoke the "wave_utils" tool
(e.g., call wave_utils with the plan_create or plan_update action) and keep the
existing guidance about executing one step at a time and continuing when an
<active_plan> block is present so the model emits valid tool invocations.

Comment on lines +153 to +191
data, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("marshaling request: %w", err)
}
data = append(data, '\n')

if _, err := c.stdin.Write(data); err != nil {
return fmt.Errorf("writing to stdin: %w", err)
}

// Read response line with timeout via channel
type readResult struct {
line []byte
err error
}
ch := make(chan readResult, 1)
go func() {
line, err := c.stdout.ReadBytes('\n')
ch <- readResult{line, err}
}()

select {
case res := <-ch:
if res.err != nil {
return fmt.Errorf("reading response: %w", res.err)
}
var resp JsonRpcResponse
if err := json.Unmarshal(res.line, &resp); err != nil {
return fmt.Errorf("parsing response: %w", err)
}
if resp.Error != nil {
return fmt.Errorf("RPC error %d: %s", resp.Error.Code, resp.Error.Message)
}
if result != nil && resp.Result != nil {
if err := json.Unmarshal(resp.Result, result); err != nil {
return fmt.Errorf("parsing result: %w", err)
}
}
return nil
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Model Context Protocol stdio transport framing: do stdio messages use Content-Length headers for JSON-RPC messages, and can servers send notifications interleaved with responses that must be matched by request id?

💡 Result:

No, stdio messages in the Model Context Protocol do not use Content-Length headers. They use newline-delimited JSON-RPC messages. Yes, servers can send notifications interleaved with responses, and these must be matched to requests by their JSON-RPC request id where applicable (responses match requests by id; notifications have no id).

Citations:


Request-response matching and goroutine lifecycle are required for MCP compliance.

At lines 172–188, responses are unmarshaled without matching against the request's id. MCP servers can send notifications interleaved with responses; this code will misroute them. Also, the goroutine spawned at line 169 will remain blocked on c.stdout.ReadBytes('\n') if the timeout fires, leaving a dangling reader and potential resource leak. Implement a per-request dispatcher keyed by id, cancel or reuse the reader lifecycle on timeout, and handle notifications separately from responses.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/mcpclient/mcpclient.go` around lines 153 - 191, The current per-call
goroutine using c.stdout.ReadBytes and the ad-hoc readResult channel must be
replaced with a request/response dispatcher to avoid misrouting notifications
and leaking readers: implement a single dedicated reader goroutine that
continuously reads lines from c.stdout, unmarshals into JsonRpcResponse, and
dispatches by resp.ID to a synchronized map[id]chan JsonRpcResponse (handle
resp.ID == nil as a server notification path); update the sending function
(which writes to c.stdin and waits) to register a response channel keyed by the
request id, wait with timeout on that channel, and unregister/close the channel
on timeout to avoid leaks; ensure the reader goroutine never blocks on
per-request waits and that notifications are processed via a separate handler
callback or channel.

Comment on lines +168 to +194
ch := make(chan readResult, 1)
go func() {
line, err := c.stdout.ReadBytes('\n')
ch <- readResult{line, err}
}()

select {
case res := <-ch:
if res.err != nil {
return fmt.Errorf("reading response: %w", res.err)
}
var resp JsonRpcResponse
if err := json.Unmarshal(res.line, &resp); err != nil {
return fmt.Errorf("parsing response: %w", err)
}
if resp.Error != nil {
return fmt.Errorf("RPC error %d: %s", resp.Error.Code, resp.Error.Message)
}
if result != nil && resp.Result != nil {
if err := json.Unmarshal(resp.Result, result); err != nil {
return fmt.Errorf("parsing result: %w", err)
}
}
return nil
case <-time.After(ReadTimeout):
return fmt.Errorf("timeout waiting for response to %q after %v", method, ReadTimeout)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

A timed-out call leaves a live reader on c.stdout.

At Lines 168-194, if time.After fires, the goroutine blocked in ReadBytes keeps running after call() returns. The next RPC can then start a second reader on the same bufio.Reader, which will corrupt response delivery or hang both calls. Close the client on timeout or move all reads into a single dedicated reader goroutine.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/mcpclient/mcpclient.go` around lines 168 - 194, The current per-call
goroutine that does c.stdout.ReadBytes('\n') in call() can leak when the select
times out, leaving a live reader on c.stdout; fix by either (A) ensuring the
blocking read is canceled on timeout by closing the client/process (call
c.Close() or the underlying io to unblock ReadBytes) before returning from
call() when the time.After fires, or (B) refactor to a single dedicated reader
goroutine that continuously reads lines from c.stdout, parses JsonRpcResponse,
and dispatches results to the waiting call via a response channel keyed by
request (e.g., attach an ID map from request ID to chan JsonRpcResponse), then
update call() to send the request and wait only on that response channel instead
of spawning a ReadBytes goroutine; reference c.stdout, call(), ReadTimeout,
readResult, and JsonRpcResponse to locate and change the logic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants